feat: Implement Meeting Summary Remote Extension This commit implements the Meeting Summary Remote Extension, allowing a remote surface to receive meeting summary information (current speaker and participant count) from the connected VoIP application. Key changes: - **Added `addMeetingSummaryExtension` function:** Provides the entry point for the extension. This function takes callbacks for current speaker and participant count changes, and returns a `MeetingSummaryRemote` instance. - **Added `MeetingSummaryRemote` interface:** Defines the contract for interacting with the remote meeting summary service. Currently, this interface primarily exposes an `isSupported` property to indicate if the connected voip application supports the meeting summary extension. - **Implemented `MeetingSummaryRemoteImpl`:** Provides a concrete implementation of `MeetingSummaryRemote`. This class handles the capability exchange with the remote surface, establishes communication, and sends updates regarding the current speaker and participant count. - **Added End-to-End Test:** Created an end-to-end test to verify the complete flow, from capability exchange to sending updates. - **Implemented in Test App:** Manually tested by integrating the new API into the test application. This feature enables a remote surface to display real-time updates of participant data for the purpose of generating a meeting summary. RelNote: added a new remote surface Meeting Summary extension Test: e2e + manual Fixes: 392960062 Change-Id: I0989d42df4fb802c4d931451814abe177f210a43 
diff --git a/core/core-telecom/api/1.0.0-beta01.txt b/core/core-telecom/api/1.0.0-beta01.txt index 7813332..c9255ed 100644 --- a/core/core-telecom/api/1.0.0-beta01.txt +++ b/core/core-telecom/api/1.0.0-beta01.txt 
@@ -149,6 +149,7 @@  @SuppressCompatibility @androidx.core.telecom.util.ExperimentalAppActions public interface CallExtensionScope {  method public androidx.core.telecom.extensions.CallIconExtensionRemote addCallIconSupport(kotlin.jvm.functions.Function2<? super android.net.Uri,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> onCallIconChanged);  method public androidx.core.telecom.extensions.LocalCallSilenceExtensionRemote addLocalCallSilenceExtension(kotlin.jvm.functions.Function2<? super java.lang.Boolean,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> onIsLocallySilencedUpdated); + method public androidx.core.telecom.extensions.MeetingSummaryRemote addMeetingSummaryExtension(kotlin.jvm.functions.Function2<? super java.lang.String?,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> onCurrentSpeakerChanged, kotlin.jvm.functions.Function2<? super java.lang.Integer,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> onParticipantCountChanged);  method public androidx.core.telecom.extensions.ParticipantExtensionRemote addParticipantExtension(kotlin.jvm.functions.Function2<? super androidx.core.telecom.extensions.Participant?,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> onActiveParticipantChanged, kotlin.jvm.functions.Function2<? super java.util.Set<? extends androidx.core.telecom.extensions.Participant>,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> onParticipantsUpdated);  method public void onConnected(kotlin.jvm.functions.Function2<? super android.telecom.Call,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> block);  } @@ -194,6 +195,11 @@  property public abstract boolean isSupported;  }   + @SuppressCompatibility @androidx.core.telecom.util.ExperimentalAppActions public interface MeetingSummaryRemote { + method public boolean isSupported(); + property public abstract boolean isSupported; + } +  @SuppressCompatibility @androidx.core.telecom.util.ExperimentalAppActions public final class Participant {  ctor public Participant(String id, CharSequence name);  method public String getId(); 
diff --git a/core/core-telecom/api/current.txt b/core/core-telecom/api/current.txt index 7813332..c9255ed 100644 --- a/core/core-telecom/api/current.txt +++ b/core/core-telecom/api/current.txt 
@@ -149,6 +149,7 @@  @SuppressCompatibility @androidx.core.telecom.util.ExperimentalAppActions public interface CallExtensionScope {  method public androidx.core.telecom.extensions.CallIconExtensionRemote addCallIconSupport(kotlin.jvm.functions.Function2<? super android.net.Uri,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> onCallIconChanged);  method public androidx.core.telecom.extensions.LocalCallSilenceExtensionRemote addLocalCallSilenceExtension(kotlin.jvm.functions.Function2<? super java.lang.Boolean,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> onIsLocallySilencedUpdated); + method public androidx.core.telecom.extensions.MeetingSummaryRemote addMeetingSummaryExtension(kotlin.jvm.functions.Function2<? super java.lang.String?,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> onCurrentSpeakerChanged, kotlin.jvm.functions.Function2<? super java.lang.Integer,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> onParticipantCountChanged);  method public androidx.core.telecom.extensions.ParticipantExtensionRemote addParticipantExtension(kotlin.jvm.functions.Function2<? super androidx.core.telecom.extensions.Participant?,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> onActiveParticipantChanged, kotlin.jvm.functions.Function2<? super java.util.Set<? extends androidx.core.telecom.extensions.Participant>,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> onParticipantsUpdated);  method public void onConnected(kotlin.jvm.functions.Function2<? super android.telecom.Call,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> block);  } @@ -194,6 +195,11 @@  property public abstract boolean isSupported;  }   + @SuppressCompatibility @androidx.core.telecom.util.ExperimentalAppActions public interface MeetingSummaryRemote { + method public boolean isSupported(); + property public abstract boolean isSupported; + } +  @SuppressCompatibility @androidx.core.telecom.util.ExperimentalAppActions public final class Participant {  ctor public Participant(String id, CharSequence name);  method public String getId(); 
diff --git a/core/core-telecom/api/restricted_1.0.0-beta01.txt b/core/core-telecom/api/restricted_1.0.0-beta01.txt index 7813332..c9255ed 100644 --- a/core/core-telecom/api/restricted_1.0.0-beta01.txt +++ b/core/core-telecom/api/restricted_1.0.0-beta01.txt 
@@ -149,6 +149,7 @@  @SuppressCompatibility @androidx.core.telecom.util.ExperimentalAppActions public interface CallExtensionScope {  method public androidx.core.telecom.extensions.CallIconExtensionRemote addCallIconSupport(kotlin.jvm.functions.Function2<? super android.net.Uri,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> onCallIconChanged);  method public androidx.core.telecom.extensions.LocalCallSilenceExtensionRemote addLocalCallSilenceExtension(kotlin.jvm.functions.Function2<? super java.lang.Boolean,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> onIsLocallySilencedUpdated); + method public androidx.core.telecom.extensions.MeetingSummaryRemote addMeetingSummaryExtension(kotlin.jvm.functions.Function2<? super java.lang.String?,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> onCurrentSpeakerChanged, kotlin.jvm.functions.Function2<? super java.lang.Integer,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> onParticipantCountChanged);  method public androidx.core.telecom.extensions.ParticipantExtensionRemote addParticipantExtension(kotlin.jvm.functions.Function2<? super androidx.core.telecom.extensions.Participant?,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> onActiveParticipantChanged, kotlin.jvm.functions.Function2<? super java.util.Set<? extends androidx.core.telecom.extensions.Participant>,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> onParticipantsUpdated);  method public void onConnected(kotlin.jvm.functions.Function2<? super android.telecom.Call,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> block);  } @@ -194,6 +195,11 @@  property public abstract boolean isSupported;  }   + @SuppressCompatibility @androidx.core.telecom.util.ExperimentalAppActions public interface MeetingSummaryRemote { + method public boolean isSupported(); + property public abstract boolean isSupported; + } +  @SuppressCompatibility @androidx.core.telecom.util.ExperimentalAppActions public final class Participant {  ctor public Participant(String id, CharSequence name);  method public String getId(); 
diff --git a/core/core-telecom/api/restricted_current.txt b/core/core-telecom/api/restricted_current.txt index 7813332..c9255ed 100644 --- a/core/core-telecom/api/restricted_current.txt +++ b/core/core-telecom/api/restricted_current.txt 
@@ -149,6 +149,7 @@  @SuppressCompatibility @androidx.core.telecom.util.ExperimentalAppActions public interface CallExtensionScope {  method public androidx.core.telecom.extensions.CallIconExtensionRemote addCallIconSupport(kotlin.jvm.functions.Function2<? super android.net.Uri,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> onCallIconChanged);  method public androidx.core.telecom.extensions.LocalCallSilenceExtensionRemote addLocalCallSilenceExtension(kotlin.jvm.functions.Function2<? super java.lang.Boolean,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> onIsLocallySilencedUpdated); + method public androidx.core.telecom.extensions.MeetingSummaryRemote addMeetingSummaryExtension(kotlin.jvm.functions.Function2<? super java.lang.String?,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> onCurrentSpeakerChanged, kotlin.jvm.functions.Function2<? super java.lang.Integer,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> onParticipantCountChanged);  method public androidx.core.telecom.extensions.ParticipantExtensionRemote addParticipantExtension(kotlin.jvm.functions.Function2<? super androidx.core.telecom.extensions.Participant?,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> onActiveParticipantChanged, kotlin.jvm.functions.Function2<? super java.util.Set<? extends androidx.core.telecom.extensions.Participant>,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> onParticipantsUpdated);  method public void onConnected(kotlin.jvm.functions.Function2<? super android.telecom.Call,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,? extends java.lang.Object?> block);  } @@ -194,6 +195,11 @@  property public abstract boolean isSupported;  }   + @SuppressCompatibility @androidx.core.telecom.util.ExperimentalAppActions public interface MeetingSummaryRemote { + method public boolean isSupported(); + property public abstract boolean isSupported; + } +  @SuppressCompatibility @androidx.core.telecom.util.ExperimentalAppActions public final class Participant {  ctor public Participant(String id, CharSequence name);  method public String getId(); 
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/CallData.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/CallData.kt index 6b85ed1..b655d59 100644 --- a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/CallData.kt +++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/CallData.kt 
@@ -102,6 +102,9 @@  /** data related to the call icon extension */  data class CallIconData(val callIconUri: Bitmap)   +/** data related to the Meeting Summary extension */ +data class MeetingSummaryData(val activeSpeaker: String, val participantCount: Int) +  @OptIn(ExperimentalAppActions::class)  data class RaiseHandData(val raisedHands: List<Participant>, val raiseHandAction: RaiseHandAction)   @@ -111,6 +114,7 @@  /** Combined call data including extensions. */  data class CallData(  val callData: BaseCallData, + val meetingSummaryData: MeetingSummaryData,  val participantExtensionData: ParticipantExtensionData?,  val localSilenceData: LocalCallSilenceData?,  val callIconData: CallIconData? 
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/CallDataEmitters.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/CallDataEmitters.kt index ccfa3e7..cf168c6 100644 --- a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/CallDataEmitters.kt +++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/CallDataEmitters.kt 
@@ -137,6 +137,65 @@  }    /** + * A class responsible for emitting meeting summary data. + * + * This class tracks changes to participant count and the current speaker, combining them into a + * [MeetingSummaryData] object and emitting it as a [Flow]. + */ +class MeetingSummaryExtensionDataEmitter { + companion object { + /** The default value for the current speaker when no speaker is active. */ + const val CURRENT_SPEAKER_DEFAULT = "" + + /** The default value for the participant count when no participants are present. */ + const val PARTICIPANT_COUNT_DEFAULT = 0 + } + + private val currentSpeaker: MutableStateFlow<String> = MutableStateFlow(CURRENT_SPEAKER_DEFAULT) + private val participantCount: MutableStateFlow<Int> = + MutableStateFlow(PARTICIPANT_COUNT_DEFAULT) + + /** + * Updates the participant count. + * + * @param newCount The new number of participants in the meeting. + */ + fun onParticipantCountChanged(newCount: Int) { + participantCount.value = newCount + } + + /** + * Updates the current speaker. + * + * @param speaker The name or identifier of the current speaker. + */ + fun onCurrentSpeakerChanged(speaker: String?) { + if (speaker == null) { + currentSpeaker.value = "no active speaker" + } else { + currentSpeaker.value = speaker + } + } + + /** + * Collects the current speaker and participant count, combining them into a + * [MeetingSummaryData] flow. + * + * This function uses the `combine` operator to merge the latest values from the + * `participantCount` and `currentSpeaker` StateFlows. Whenever either value changes, a new + * [MeetingSummaryData] object is emitted. + * + * @return A [Flow] of [MeetingSummaryData] objects representing the current state of the + * meeting summary. + */ + fun collect(): Flow<MeetingSummaryData> { + return participantCount.combine(currentSpeaker) { count, speaker -> + MeetingSummaryData(speaker, count) + } + } +} + +/**  * Track and update listeners when the [ParticipantExtensionData] related to a call changes,  * including the optional raise hand and kick participant extensions.  */ 
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/InCallServiceImpl.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/InCallServiceImpl.kt index a0b1d64..f9ed54e 100644 --- a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/InCallServiceImpl.kt +++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/services/InCallServiceImpl.kt 
@@ -165,6 +165,12 @@  onParticipantsUpdated = participantsEmitter::onParticipantsChanged  )   + val meetingSummaryEmitter = MeetingSummaryExtensionDataEmitter() + addMeetingSummaryExtension( + onCurrentSpeakerChanged = meetingSummaryEmitter::onCurrentSpeakerChanged, + onParticipantCountChanged = meetingSummaryEmitter::onParticipantCountChanged + ) +  val kickParticipantDataEmitter = KickParticipantDataEmitter()  val kickParticipantAction = participantExtension.addKickParticipantAction()   @@ -215,6 +221,8 @@  onConnected {  val callData = CallDataEmitter(IcsCall(currId.getAndAdd(1), call)).collect()   + val meetingSummaryData = meetingSummaryEmitter.collect() +  val participantData =  participantsEmitter.collect(  participantExtension.isSupported, @@ -230,11 +238,12 @@  val fullData =  combine(  callData, + meetingSummaryData,  participantData,  localCallSilenceData,  callIconData - ) { cd, partData, silenceData, iconData -> - CallData(cd, partData, silenceData, iconData) + ) { cd, summary, partData, silenceData, iconData -> + CallData(cd, summary, partData, silenceData, iconData)  }  mCallDataAggregator.watch(this@launch, fullData)  } 
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/CallUiState.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/CallUiState.kt index 6458b9c..3922cf6 100644 --- a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/CallUiState.kt +++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/CallUiState.kt 
@@ -41,6 +41,7 @@  val direction: Direction,  val callType: CallType,  val onStateChanged: (transition: CallStateTransition) -> Unit, + val meetingSummaryUiState: MeetingSummaryUiState,  val participantUiState: ParticipantExtensionUiState?,  val localCallSilenceUiState: LocalCallSilenceExtensionUiState?,  val callIconUiState: CallIconExtensionUiState? 
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/CallsScreen.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/CallsScreen.kt index cf1f49c..b0672b3 100644 --- a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/CallsScreen.kt +++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/CallsScreen.kt 
@@ -357,6 +357,7 @@  HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp))  ExtensionsContent(  ExtensionUiState( + caller.meetingSummaryUiState,  caller.localCallSilenceUiState,  caller.participantUiState,  caller.callIconUiState 
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/ExtensionsContent.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/ExtensionsContent.kt index 8e182e8..2213770 100644 --- a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/ExtensionsContent.kt +++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/ExtensionsContent.kt 
@@ -16,6 +16,7 @@    package androidx.core.telecom.test.ui.calling   +import android.graphics.Bitmap  import androidx.compose.foundation.BorderStroke  import androidx.compose.foundation.Image  import androidx.compose.foundation.layout.Column @@ -60,6 +61,7 @@  import kotlinx.coroutines.launch    data class ExtensionUiState( + val meetingSummaryUiState: MeetingSummaryUiState,  val localCallSilenceUiState: LocalCallSilenceExtensionUiState?,  val participantUiState: ParticipantExtensionUiState?,  val callIconUiState: CallIconExtensionUiState? @@ -70,6 +72,7 @@  override val values =  sequenceOf(  ExtensionUiState( + MeetingSummaryUiState("John Smith", 1),  LocalCallSilenceExtensionUiState(true, {}, null),  ParticipantExtensionUiState(  isRaiseHandSupported = true, @@ -126,7 +129,10 @@  verticalAlignment = Alignment.CenterVertically  ) {  Image( - bitmap = extensionUiState.callIconUiState.bitmap!!.asImageBitmap(), + bitmap = + extensionUiState.callIconUiState.bitmap?.asImageBitmap() + ?: Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + .asImageBitmap(),  contentDescription = "Bitmap Image",  contentScale = ContentScale.Fit,  modifier = Modifier.size(48.dp) @@ -181,42 +187,53 @@  }    HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp)) - - Text("Participants") - if (extensionUiState.participantUiState == null) { - Text( - modifier = Modifier.fillMaxWidth().padding(6.dp), - text = "<Participants is NOT supported>" - ) - } else if (extensionUiState.participantUiState.participants.isEmpty()) { - Text(modifier = Modifier.padding(horizontal = 6.dp), text = "<No Participants>") - } else { - Column( - modifier = - Modifier.height(150.dp) - .fillMaxWidth() - .padding(6.dp) - .verticalScroll(rememberScrollState()) + Column(modifier = Modifier.weight(1f).padding(6.dp)) { + Text("Participants") + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically  ) { - extensionUiState.participantUiState.participants.forEach { - if (it.isActive) { - ActiveParticipantContent( - extensionUiState.participantUiState.isKickParticipantSupported, - extensionUiState.participantUiState.isRaiseHandSupported, - onRaiseHandStateChanged = - extensionUiState.participantUiState.onRaiseHandStateChanged, - it - ) - } else { - NonActiveParticipantContent( - extensionUiState.participantUiState.isKickParticipantSupported, - extensionUiState.participantUiState.isRaiseHandSupported, - onRaiseHandStateChanged = - extensionUiState.participantUiState.onRaiseHandStateChanged, - it - ) + Text( + "Participant Count: ${extensionUiState.meetingSummaryUiState.participantCount}" + ) + Spacer(modifier = Modifier.weight(1f)) + Text("Active Speaker: ${extensionUiState.meetingSummaryUiState.activeSpeaker}") + } + if (extensionUiState.participantUiState == null) { + Text( + modifier = Modifier.fillMaxWidth().padding(6.dp), + text = "<Participants is NOT supported>" + ) + } else if (extensionUiState.participantUiState.participants.isEmpty()) { + Text(modifier = Modifier.padding(horizontal = 6.dp), text = "<No Participants>") + } else { + Column( + modifier = + Modifier.height(150.dp) + .fillMaxWidth() + .padding(6.dp) + .verticalScroll(rememberScrollState()) + ) { + extensionUiState.participantUiState.participants.forEach { + if (it.isActive) { + ActiveParticipantContent( + extensionUiState.participantUiState.isKickParticipantSupported, + extensionUiState.participantUiState.isRaiseHandSupported, + onRaiseHandStateChanged = + extensionUiState.participantUiState.onRaiseHandStateChanged, + it + ) + } else { + NonActiveParticipantContent( + extensionUiState.participantUiState.isKickParticipantSupported, + extensionUiState.participantUiState.isRaiseHandSupported, + onRaiseHandStateChanged = + extensionUiState.participantUiState.onRaiseHandStateChanged, + it + ) + } + Spacer(Modifier.padding(vertical = 6.dp))  } - Spacer(Modifier.padding(vertical = 6.dp))  }  }  } 
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/MeetingSummaryUiState.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/MeetingSummaryUiState.kt new file mode 100644 index 0000000..0bd4c0f --- /dev/null +++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/MeetingSummaryUiState.kt 
@@ -0,0 +1,19 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.core.telecom.test.ui.calling + +data class MeetingSummaryUiState(val activeSpeaker: String, val participantCount: Int) 
diff --git a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/OngoingCallsViewModel.kt b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/OngoingCallsViewModel.kt index 063abefc..5e4dd70 100644 --- a/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/OngoingCallsViewModel.kt +++ b/core/core-telecom/integration-tests/testicsapp/src/main/java/androidx/core/telecom/test/ui/calling/OngoingCallsViewModel.kt 
@@ -33,6 +33,7 @@  import androidx.core.telecom.test.services.CallState  import androidx.core.telecom.test.services.Capability  import androidx.core.telecom.test.services.LocalCallSilenceData +import androidx.core.telecom.test.services.MeetingSummaryData  import androidx.core.telecom.test.services.ParticipantExtensionData  import androidx.core.telecom.test.services.RemoteCallProvider  import androidx.core.telecom.util.ExperimentalAppActions @@ -137,12 +138,28 @@  direction = fullCallData.callData.direction,  callType = fullCallData.callData.callType,  onStateChanged = { fullCallData.callData.onStateChanged(it) }, + meetingSummaryUiState = mapToUiMeetingSummaryExtension(fullCallData.meetingSummaryData),  participantUiState = mapToUiParticipantExtension(fullCallData.participantExtensionData),  localCallSilenceUiState = mapToUiLocalSilenceExtension(fullCallData.localSilenceData),  callIconUiState = mapToUiCallIconExtension(fullCallData.callIconData)  )  }   + /** map [CallIconData] to [MeetingSummaryUiState] */ + @OptIn(ExperimentalAppActions::class) + private fun mapToUiMeetingSummaryExtension( + meetingSummaryData: MeetingSummaryData? + ): MeetingSummaryUiState { + return if (meetingSummaryData == null) { + MeetingSummaryUiState("", 0) + } else { + MeetingSummaryUiState( + meetingSummaryData.activeSpeaker, + meetingSummaryData.participantCount + ) + } + } +  /** map [CallIconData] to [CallIconExtensionUiState] */  @OptIn(ExperimentalAppActions::class)  private fun mapToUiCallIconExtension( 
diff --git a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/E2EExtensionTests.kt b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/E2EExtensionTests.kt index 5699846..7fa0709 100644 --- a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/E2EExtensionTests.kt +++ b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/E2EExtensionTests.kt 
@@ -146,6 +146,32 @@  }  }   + internal class CachedMeetingSummary(scope: CallExtensionScope) { + private val participantState = MutableStateFlow<Int>(0) + private val activeParticipantState = MutableStateFlow<String?>("") + val extension = + scope.addMeetingSummaryExtension( + onCurrentSpeakerChanged = activeParticipantState::emit, + onParticipantCountChanged = participantState::emit + ) + + suspend fun waitForParticipantCount(expected: Int) { + val result = + withTimeoutOrNull(ICS_EXTENSION_UPDATE_TIMEOUT_MS) { + participantState.first { it == expected } + } + assertEquals("Never received expected participant count update", expected, result) + } + + suspend fun waitForActiveParticipant(expected: String?) { + val result = + withTimeoutOrNull(ICS_EXTENSION_UPDATE_TIMEOUT_MS) { + activeParticipantState.first { it == expected } + } + assertEquals("Never received expected active participant", expected, result) + } + } +  // TODO:: b/364316364 should assert on a per call basis  internal class CachedLocalSilence(scope: CallExtensionScope) {  private val isLocallySilenced = MutableStateFlow(false) @@ -286,6 +312,7 @@  with(ics) {  connectExtensions(call) {  val participants = CachedParticipants(this) + val meetingSummary = CachedMeetingSummary(this)  onConnected {  hasConnected = true  assertTrue( @@ -294,18 +321,23 @@  )  // Wait for initial state  participants.waitForParticipants(emptySet()) + meetingSummary.waitForParticipantCount(0)  participants.waitForActiveParticipant(null) + meetingSummary.waitForActiveParticipant(null)  // Test VOIP -> ICS connection by updating state  val currentParticipants = TestUtils.generateParticipants(2)  voipAppControl.updateParticipants(  currentParticipants.map { it.toParticipantParcelable() }  )  participants.waitForParticipants(currentParticipants.toSet()) + meetingSummary.waitForParticipantCount(currentParticipants.size)  voipAppControl.updateActiveParticipant(  currentParticipants[0].toParticipantParcelable()  )  participants.waitForActiveParticipant(currentParticipants[0]) - + meetingSummary.waitForActiveParticipant( + currentParticipants[0].name.toString() + )  call.disconnect()  }  } 
diff --git a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/ExtensionAidlTest.kt b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/ExtensionAidlTest.kt index e233bbf..6f665cc 100644 --- a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/ExtensionAidlTest.kt +++ b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/ExtensionAidlTest.kt 
@@ -22,6 +22,7 @@  import androidx.core.telecom.extensions.ICapabilityExchange  import androidx.core.telecom.extensions.ICapabilityExchangeListener  import androidx.core.telecom.extensions.ILocalSilenceStateListener +import androidx.core.telecom.extensions.IMeetingSummaryStateListener  import androidx.core.telecom.extensions.IParticipantStateListener  import androidx.core.telecom.util.ExperimentalAppActions  import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -98,6 +99,13 @@  TODO("Not yet implemented")  }   + override fun onCreateMeetingSummaryExtension( + version: Int, + l: IMeetingSummaryStateListener? + ) { + TODO("Not yet implemented") + } +  override fun onRemoveExtensions() {  unsubscribeFromParticipantExtensionUpdatse()  unsubscribeFromCallDetailsExtensionUpdates() 
diff --git a/core/core-telecom/src/main/aidl/androidx/core/telecom/extensions/ICapabilityExchangeListener.aidl b/core/core-telecom/src/main/aidl/androidx/core/telecom/extensions/ICapabilityExchangeListener.aidl index 019bc8b..9ace173 100644 --- a/core/core-telecom/src/main/aidl/androidx/core/telecom/extensions/ICapabilityExchangeListener.aidl +++ b/core/core-telecom/src/main/aidl/androidx/core/telecom/extensions/ICapabilityExchangeListener.aidl 
@@ -22,6 +22,7 @@  import androidx.core.telecom.extensions.ICallDetailsListener;  import androidx.core.telecom.extensions.ILocalSilenceStateListener;  import androidx.core.telecom.extensions.ICallIconStateListener; +import androidx.core.telecom.extensions.IMeetingSummaryStateListener;    // ICS Client -> VOIP app  @JavaPassthrough(annotation="@androidx.core.telecom.util.ExperimentalAppActions") @@ -37,4 +38,5 @@  // V1 - no actions, only the ability to toggle the isLocallySilenced value  void onCreateLocalCallSilenceExtension(in int version, in int[] actions, in ILocalSilenceStateListener l) = 3;  void onCreateCallIconExtension(in int version, in int[] actions, in String remoteName, in ICallIconStateListener l) = 4; + void onCreateMeetingSummaryExtension(in int version, in IMeetingSummaryStateListener l) = 5;  } \ No newline at end of file 
diff --git a/core/core-telecom/src/main/aidl/androidx/core/telecom/extensions/IMeetingSummaryStateListener.aidl b/core/core-telecom/src/main/aidl/androidx/core/telecom/extensions/IMeetingSummaryStateListener.aidl new file mode 100644 index 0000000..b078091 --- /dev/null +++ b/core/core-telecom/src/main/aidl/androidx/core/telecom/extensions/IMeetingSummaryStateListener.aidl 
@@ -0,0 +1,27 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.core.telecom.extensions; + +import android.net.Uri; + +// VOIP app -> ICS Client +@JavaPassthrough(annotation="@androidx.core.telecom.util.ExperimentalAppActions") +@JavaPassthrough(annotation="@androidx.annotation.RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY)") +oneway interface IMeetingSummaryStateListener { +void updateCurrentSpeaker(in String speakerName)= 0; +void updateParticipantCount(in int participantCount)= 1; +void finishSync() = 2; +} \ No newline at end of file 
diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/extensions/CallExtensionScope.kt b/core/core-telecom/src/main/java/androidx/core/telecom/extensions/CallExtensionScope.kt index 3b3122a..83b5566 100644 --- a/core/core-telecom/src/main/java/androidx/core/telecom/extensions/CallExtensionScope.kt +++ b/core/core-telecom/src/main/java/androidx/core/telecom/extensions/CallExtensionScope.kt 
@@ -86,6 +86,50 @@  ): ParticipantExtensionRemote    /** + * Add support for this remote surface to display meeting summary information for this call. + * + * This function establishes a connection with a remote service that provides meeting summary + * information, such as the current speaker and the number of participants. The extension will + * provide updates via the provided callbacks: + * + * @param onCurrentSpeakerChanged A suspend function that is called whenever the current speaker + * in the meeting changes. The function receives a [String] representing the new speaker's + * identifier (e.g., name or ID) or null if there is no current speaker. + * @param onParticipantCountChanged A suspend function that is called whenever the number of + * participants in the meeting changes. The function receives an [Int] representing the new + * participant count. + * @return A [MeetingSummaryRemote] object with an `isSupported` property of this object will + * indicate whether the meeting summary extension is supported by the calling application. + * + * Example Usage: + * ```kotlin + * connectExtensions(call) { + * val meetingSummaryRemote = addMeetingSummaryExtension( + * onCurrentSpeakerChanged = { speaker -> + * // Update UI with the new speaker + * Log.d(TAG, "Current speaker: $speaker") + * }, + * onParticipantCountChanged = { count -> + * // Update UI with the new participant count + * Log.d(TAG, "Participant count: $count") + * } + * ) + * onConnected { + * if (meetingSummaryRemote.isSupported) { + * // The extension is ready to use + * } else { + * // Handle the case where the extension is not supported. + * } + * } + * } + * ``` + */ + public fun addMeetingSummaryExtension( + onCurrentSpeakerChanged: suspend (String?) -> Unit, + onParticipantCountChanged: suspend (Int) -> Unit + ): MeetingSummaryRemote + + /**  * Add support for this remote surface to display information related to the local call silence  * state for this call.  * 
diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/extensions/CallExtensionScopeImpl.kt b/core/core-telecom/src/main/java/androidx/core/telecom/extensions/CallExtensionScopeImpl.kt index 29dc80a..57de85f 100644 --- a/core/core-telecom/src/main/java/androidx/core/telecom/extensions/CallExtensionScopeImpl.kt +++ b/core/core-telecom/src/main/java/androidx/core/telecom/extensions/CallExtensionScopeImpl.kt 
@@ -154,6 +154,26 @@  return extension  }   + override fun addMeetingSummaryExtension( + onCurrentSpeakerChanged: suspend (String?) -> Unit, + onParticipantCountChanged: suspend (Int) -> Unit + ): MeetingSummaryRemote { + val extension = + MeetingSummaryRemoteImpl(callScope, onCurrentSpeakerChanged, onParticipantCountChanged) + registerExtension { + CallExtensionCreator( + extensionCapability = + Capability().apply { + featureId = Extensions.MEETING_SUMMARY + featureVersion = ParticipantExtensionImpl.MEETING_SUMMARY_VERSION + supportedActions = extension.actions + }, + onExchangeComplete = extension::onExchangeComplete + ) + } + return extension + } +  override fun addLocalCallSilenceExtension(  onIsLocallySilencedUpdated: suspend (Boolean) -> Unit  ): LocalCallSilenceExtensionRemoteImpl { 
diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/extensions/ExtensionInitializationScopeImpl.kt b/core/core-telecom/src/main/java/androidx/core/telecom/extensions/ExtensionInitializationScopeImpl.kt index 41625cb..753a0b1 100644 --- a/core/core-telecom/src/main/java/androidx/core/telecom/extensions/ExtensionInitializationScopeImpl.kt +++ b/core/core-telecom/src/main/java/androidx/core/telecom/extensions/ExtensionInitializationScopeImpl.kt 
@@ -71,7 +71,8 @@  initialActiveParticipant: Participant?  ): ParticipantExtension {  val participant = ParticipantExtensionImpl(initialParticipants, initialActiveParticipant) - registerExtension(onExchangeStarted = participant::onExchangeStarted) + registerExtension(onExchangeStarted = participant::onParticipantExchangeStarted) + registerExtension(onExchangeStarted = participant::onMeetingSummaryExchangeStarted)  return participant  }   
diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/extensions/Extensions.kt b/core/core-telecom/src/main/java/androidx/core/telecom/extensions/Extensions.kt index 4709bc6..6c61005 100644 --- a/core/core-telecom/src/main/java/androidx/core/telecom/extensions/Extensions.kt +++ b/core/core-telecom/src/main/java/androidx/core/telecom/extensions/Extensions.kt 
@@ -44,4 +44,6 @@  internal const val LOCAL_CALL_SILENCE = 2  /** Represents the [CallIconExtension] extension */  internal const val CALL_ICON = 3 + /** Represents a more lightweight [ParticipantExtension] extension */ + internal const val MEETING_SUMMARY = 4  } 
diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/extensions/MeetingSummaryRemote.kt b/core/core-telecom/src/main/java/androidx/core/telecom/extensions/MeetingSummaryRemote.kt new file mode 100644 index 0000000..3cdabe5 --- /dev/null +++ b/core/core-telecom/src/main/java/androidx/core/telecom/extensions/MeetingSummaryRemote.kt 
@@ -0,0 +1,37 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.core.telecom.extensions + +import androidx.core.telecom.util.ExperimentalAppActions + +/** + * Interface used to allow the remote surface (automotive, watch, etc...) to know if the connected + * calling application supports the meeting summary extension + */ +@ExperimentalAppActions +public interface MeetingSummaryRemote { + /** + * Whether or not the meeting summary extension is supported by the calling application. + * + * If `true`, then updates about meeting summary in the call will be given. If `false`, then the + * remote doesn't support this extension and updates about the meeting summary will not be + * given. + * + * Note: Must not be queried until after [CallExtensionScope.onConnected] is called. + */ + public val isSupported: Boolean +} 
diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/extensions/MeetingSummaryRemoteImpl.kt b/core/core-telecom/src/main/java/androidx/core/telecom/extensions/MeetingSummaryRemoteImpl.kt new file mode 100644 index 0000000..65fe17a --- /dev/null +++ b/core/core-telecom/src/main/java/androidx/core/telecom/extensions/MeetingSummaryRemoteImpl.kt 
@@ -0,0 +1,133 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.core.telecom.extensions + +import android.util.Log +import androidx.core.telecom.internal.CapabilityExchangeListenerRemote +import androidx.core.telecom.internal.MeetingSummaryStateListener +import androidx.core.telecom.util.ExperimentalAppActions +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.properties.Delegates +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine + +/** + * Implementation of [MeetingSummaryRemote] for handling remote meeting summary interactions. + * + * This class manages the connection and communication with a remote extension providing meeting + * summary data. It handles capability exchange and updates to the current speaker and participant + * count through callbacks. + * + * @property callScope The [CoroutineScope] used for launching coroutines within this class. Should + * be tied to the lifecycle of the component using this class. + * @property onCurrentSpeakerChanged A suspend function that is called when the current speaker + * changes. The function takes the new speaker's name/ID as a String parameter. + * @property onParticipantCountChanged A suspend function that is called when the participant count + * changes. The function takes the new participant count as an Int parameter. + */ +@ExperimentalAppActions +internal class MeetingSummaryRemoteImpl( + private val callScope: CoroutineScope, + private val onCurrentSpeakerChanged: suspend (String) -> Unit, + private val onParticipantCountChanged: suspend (Int) -> Unit +) : MeetingSummaryRemote { + + /** + * Indicates whether the remote meeting summary extension is supported. + * + * This value is set to `true` after a successful capability exchange indicates that the remote + * extension is available and compatible, and `false` otherwise. Initialized using + * [Delegates.notNull], ensuring that it's assigned a value before being read. + */ + override var isSupported by Delegates.notNull<Boolean>() + + companion object { + /** The tag used for logging. */ + val TAG: String = MeetingSummaryRemoteImpl::class.java.simpleName + } + + /** + * Returns an empty array of actions, as no actions are supported. + * + * This getter provides an IntArray, representing supported actions. In this current + * implementation, no custom actions are handled. + * + * @return An empty [IntArray]. + */ + internal val actions + get() = IntArray(0) // No actions supported + + /** + * Called when the capability exchange is complete. + * + * This method determines whether the remote extension is supported based on the negotiated + * capability and connects to the remote actions interface if supported. + * + * @param negotiatedCapability The negotiated capability. + * @param remote The remote capability exchange listener. + */ + internal suspend fun onExchangeComplete( + negotiatedCapability: Capability?, + remote: CapabilityExchangeListenerRemote? + ) { + Log.i(TAG, "onExchangeComplete: in function") + if (negotiatedCapability == null || remote == null) { + Log.i(TAG, "onNegotiated: remote is not capable") + isSupported = false + return + } + Log.i(TAG, "onExchangeComplete: isSupported") + isSupported = true + connectToRemote(negotiatedCapability, remote) + } + + /** + * Connects to the remote meeting summary extension. + * + * This private function establishes a connection with the remote extension. It creates a + * [MeetingSummaryStateListener] and passes it to the remote to receive updates on the meeting + * summary state. Uses [suspendCancellableCoroutine] to suspend until the remote side finishes + * syncing initial state. + * + * @param negotiatedCapability The [Capability] object resulting from the capability exchange. + * @param remote The [CapabilityExchangeListenerRemote] instance for communicating with the + * remote side. + */ + private suspend fun connectToRemote( + negotiatedCapability: Capability, + remote: CapabilityExchangeListenerRemote + ): Unit = suspendCancellableCoroutine { continuation -> + Log.d(TAG, "connectToRemote:") + val stateListener = + MeetingSummaryStateListener( + updateCurrentSpeaker = { callScope.launch { onCurrentSpeakerChanged(it) } }, + updateParticipantCount = { callScope.launch { onParticipantCountChanged(it) } }, + finishSync = { callScope.launch { continuation.resume(Unit) } } + ) + try { + remote.onCreateMeetingSummaryExtension( + negotiatedCapability.featureVersion, + stateListener + ) + } catch (e: Exception) { + Log.e(TAG, "Error connecting to remote extension", e) + continuation.resumeWithException(e) // Propagate the exception + } + } +} 
diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/extensions/ParticipantExtensionImpl.kt b/core/core-telecom/src/main/java/androidx/core/telecom/extensions/ParticipantExtensionImpl.kt index e3d1caa..784f80d 100644 --- a/core/core-telecom/src/main/java/androidx/core/telecom/extensions/ParticipantExtensionImpl.kt +++ b/core/core-telecom/src/main/java/androidx/core/telecom/extensions/ParticipantExtensionImpl.kt 
@@ -21,6 +21,7 @@  import androidx.annotation.IntDef  import androidx.annotation.RequiresApi  import androidx.core.telecom.internal.CapabilityExchangeRepository +import androidx.core.telecom.internal.MeetingSummaryStateListenerRemote  import androidx.core.telecom.internal.ParticipantActionCallbackRepository  import androidx.core.telecom.internal.ParticipantStateListenerRemote  import androidx.core.telecom.util.ExperimentalAppActions @@ -69,6 +70,11 @@  * whenever there is an API change to this extension or an existing action.  */  internal const val VERSION = 1 + /** + * The version of this MeetingSummaryExtension used for capability exchange. Should be + * updated whenever there is an API change to this extension or an existing action. + */ + internal const val MEETING_SUMMARY_VERSION = 1    /**  * Constants used to denote the type of action supported by the [Capability] being @@ -124,7 +130,7 @@  * Setup the participant extension creation callback receiver and return the Capability of this  * extension to be shared with the remote.  */ - internal fun onExchangeStarted(callbacks: CapabilityExchangeRepository): Capability { + internal fun onParticipantExchangeStarted(callbacks: CapabilityExchangeRepository): Capability {  callbacks.onCreateParticipantExtension = ::onCreateParticipantExtension  return Capability().apply {  featureId = Extensions.PARTICIPANT @@ -134,6 +140,21 @@  }    /** + * Setup the Meeting Summary extension creation callback receiver and return the Capability of + * this extension to be shared with the remote. + */ + internal fun onMeetingSummaryExchangeStarted( + callbacks: CapabilityExchangeRepository + ): Capability { + callbacks.onMeetingSummaryExtension = ::onCreateMeetingSummaryExtension + return Capability().apply { + featureId = Extensions.MEETING_SUMMARY + featureVersion = MEETING_SUMMARY_VERSION + supportedActions = actionRemoteConnector.keys.toIntArray() + } + } + + /**  * Register an action to this extension  *  * @param action The identifier of the action, which will be shared with the remote @@ -145,6 +166,76 @@  }    /** + * Creates and initializes the meeting summary extension. + * + * This function is responsible for setting up the meeting summary extension, synchronizing the + * initial state with the remote, and establishing listeners for changes to the participant + * count and the current speaker. + * + * The process involves: + * 1. **Initial State Synchronization:** Retrieves the initial values of the `participants` list + * and the `activeParticipant` from their respective `StateFlow`s. It then sends these + * initial values to the remote side using the provided `binder`. + * 2. **Setting up Flow Listeners:** Creates `Flow` pipelines using `onEach`, `combine`, and + * `distinctUntilChanged` to observe changes to both the `participants` list and the + * `activeParticipant`. + * - `participants.onEach`: This listener triggers whenever the `participants` list changes. + * It sends the updated participant count to the remote. + * - `combine(activeParticipant)`: This operator combines the latest values from the + * `participants` flow and the `activeParticipant` flow. It emits a new value whenever + * *either* flow emits. The lambda function checks if the `activeParticipant` is still + * present in the `participants` list. If not, it emits `null`. + * - `distinctUntilChanged`: This operator ensures that the downstream flow only receives + * updates when the value emitted by `combine` actually changes. This prevents redundant + * updates to the remote. + * - `onEach`: This listener triggers whenever the combined and filtered value changes. It + * sends the updated current speaker (or null) to the remote. + * - `launchIn(coroutineScope)`: This terminal operator launches the entire flow pipeline in + * the provided `coroutineScope`. This means the listeners will remain active as long as + * the `coroutineScope` is active. + * 3. **Finishing Synchronization:** After setting up the listeners, it calls + * `binder.finishSync()` to signal to the remote side that the initial synchronization is + * complete. + * + * @param coroutineScope The [CoroutineScope] in which the flow listeners will be launched. This + * scope should be tied to the lifecycle of the component managing the meeting summary + * extension to ensure that the listeners are automatically cancelled when the component is + * destroyed. + * @param binder The [MeetingSummaryStateListenerRemote] instance used to communicate with the + * remote side. This binder provides methods for updating the participant count and current + * speaker. + */ + private fun onCreateMeetingSummaryExtension( + coroutineScope: CoroutineScope, + binder: MeetingSummaryStateListenerRemote + ) { + Log.i(LOG_TAG, "onCreateMeetingSummaryExtension") + // sync state + val initParticipants = participants.value + val initActiveParticipant = activeParticipant.value?.name.toString() + binder.updateParticipantCount(initParticipants.size) + binder.updateCurrentSpeaker(initActiveParticipant) + // Setup listeners for changes to state + participants + .onEach { updatedParticipants -> + Log.i(LOG_TAG, "to remote: updateParticipantCount: ${updatedParticipants.size}") + binder.updateParticipantCount(updatedParticipants.size) + } + .combine(activeParticipant) { p, a -> + val result = if (a != null && p.contains(a)) a else null + Log.v(LOG_TAG, "combine: $p + $a = $result") + result + } + .distinctUntilChanged() + .onEach { + Log.i(LOG_TAG, "to remote: updateCurrentSpeaker=${it?.name.toString()}") + binder.updateCurrentSpeaker(it?.name.toString()) + } + .launchIn(coroutineScope) + binder.finishSync() + } + + /**  * Function registered to [ExtensionInitializationScope] in order to handle the creation of the  * participant extension.  * 
diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/internal/AidlExtensions.kt b/core/core-telecom/src/main/java/androidx/core/telecom/internal/AidlExtensions.kt index 212a4ce..3e30f82 100644 --- a/core/core-telecom/src/main/java/androidx/core/telecom/internal/AidlExtensions.kt +++ b/core/core-telecom/src/main/java/androidx/core/telecom/internal/AidlExtensions.kt 
@@ -27,6 +27,7 @@  import androidx.core.telecom.extensions.ICapabilityExchangeListener  import androidx.core.telecom.extensions.ILocalSilenceActions  import androidx.core.telecom.extensions.ILocalSilenceStateListener +import androidx.core.telecom.extensions.IMeetingSummaryStateListener  import androidx.core.telecom.extensions.IParticipantActions  import androidx.core.telecom.extensions.IParticipantStateListener  import androidx.core.telecom.extensions.Participant @@ -149,6 +150,42 @@  }    @ExperimentalAppActions +internal class MeetingSummaryStateListenerRemote(val binder: IMeetingSummaryStateListener) { + + fun updateCurrentSpeaker(speakerName: String) { + binder.updateCurrentSpeaker(speakerName) + } + + fun updateParticipantCount(participantCount: Int) { + binder.updateParticipantCount(participantCount) + } + + fun finishSync() { + binder.finishSync() + } +} + +@ExperimentalAppActions +internal class MeetingSummaryStateListener( + private val updateCurrentSpeaker: (String) -> Unit, + private val updateParticipantCount: (Int) -> Unit, + private val finishSync: (Unit) -> Unit +) : IMeetingSummaryStateListener.Stub() { + + override fun updateCurrentSpeaker(speakerName: String) { + updateCurrentSpeaker.invoke(speakerName) + } + + override fun updateParticipantCount(participantCount: Int) { + updateParticipantCount.invoke(participantCount) + } + + override fun finishSync() { + finishSync.invoke(Unit) + } +} + +@ExperimentalAppActions  internal class LocalCallSilenceActionsRemote(binder: ILocalSilenceActions) :  ILocalSilenceActions by binder   @@ -280,6 +317,9 @@  ((CoroutineScope, Set<Int>, String, CallIconStateListenerRemote) -> Unit)? =  null   + var onMeetingSummaryExtension: ((CoroutineScope, MeetingSummaryStateListenerRemote) -> Unit)? = + null +  val listener =  object : ICapabilityExchangeListener.Stub() {  override fun onCreateParticipantExtension( @@ -327,6 +367,18 @@  }  }   + override fun onCreateMeetingSummaryExtension( + version: Int, + l: IMeetingSummaryStateListener? + ) { + l?.let { + onMeetingSummaryExtension?.invoke( + connectionScope, + MeetingSummaryStateListenerRemote(l) + ) + } + } +  override fun onCreateCallDetailsExtension(  version: Int,  actions: IntArray?,